diff --git a/.codecov.yml b/.codecov.yml
index 1a52b318..289c17a2 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -23,6 +23,8 @@ coverage:
       tests:
         target: 100%
         paths:
+          - "!tests/utils/"
+          - "!tests/conftest.py"
           - tests/

 parsers:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4353cf45..9f18baee 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -89,28 +89,94 @@ If you make changes to `requirements_dev.txt` that are used by tests, you need t
 tox -e py37 --recreate
 ```

-To debug issues with leaving files open, you can pass `--open-files` to pytest to have tests fail that leave open files:
-https://github.com/astropy/pytest-openfile
+### Overview

-```shell
-tox -e py37 -- --open-files
+Testing wandb is tricky for a few reasons:
+
+1. `wandb.init` launches a separate process, this adds overhead and makes it difficult to assert logic happening in the backend process.
+2. The library makes lot's of requests to a W&B server as well as other services.  We don't want to make requests to an actual server so we need to mock one out.
+3. The library has many integrations with 3rd party libraries and frameworks.  We need to assert we never break compatibility with these libraries as they evolve.
+4. wandb writes files to the local file system.  When we're testing we need to make sure each test is isolated.
+5. wandb reads configuration state from global directories such as `~/.netrc` and `~/.config/wandb/settings` we need to override these in tests.
+6. The library needs to support jupyter notebook environments as well.
+
+To make our lives easier we've created lots tooling to help with the above challenges.  Most of this tooling comes in the form of [Pytest Fixtures](https://docs.pytest.org/en/stable/fixture.html).  There are detailed descriptions of our fixtures in the section below.  What follows is a general overview of writing good tests for wandb.
+
+To test functionality in the user process the `wandb_init_run` is the simplest fixture to start with.  This is like calling `wandb.init()` except we don't actually launch the wandb backend process and instead returned a mocked object you can make assertions with.  For example:
+
+```python
+def test_basic_log(wandb_init_run):
+    wandb.log({"test": 1})
+    assert wandb.run._backend.history[0]["test"] == 1
+```
+
+One of the most powerful fixtures is `live_mock_server`.  When running tests we start a Flask server that provides our graphql, filestream, and additional web service endpoints with sane defaults.  This allows us to use wandb just like we would in the real world.  It also means we can assert various requests were made.  All server logic can be found in `tests/utils/mock_server.py` and it's really straight forward to add additional logic to this server.  Here's a basic example of using the live_mock_server:
+
+```python
+def test_live_log(live_mock_server, test_settings):
+    run = wandb.init(settings=test_settings)
+    run.log({"test": 1})
+    ctx = live_mock_server.get_ctx()
+    first_stream_hist = server_ctx["file_stream"][0]["files"]["wandb-history.jsonl"]
+    assert json.loads(first_stream_hist["content"][0])["test"] == 1
 ```

-### Pytest Fixtures
+Notice we also used the `test_settings` fixture.  This turns off console logging and ensures the run is automatically finished when the test finishes.  Another really cool benefit of this fixture is it creates a run directory for the test at `tests/logs/NAME_OF_TEST`.  This is super useful for debugging because the logs are stored there. In addition to getting the debug logs you can find the live_mock_server logs at `tests/logs/live_mock_server.log`.
+
+We also have pytest fixtures that are automatically used.  These include `local_netrc` and `local_settings` this ensures we never read those settings files from your own environment.
+
+The final fixture worth noting is `notebook`.  This actually runs a jupyter notebook kernel and allows you to execute specific cells within the notebook environment:
+
+```python
+def test_one_cell(notebook):
+    with notebook("one_cell.ipynb") as nb:
+        nb.execute_all()
+        output = nb.cell_output(0)
+        assert "lovely-dawn-32" in output[-1]["data"]["text/html"]
+```

-`tests/conftest.py` contains a number of helpful fixtures automatically exposed to all tests as arguments for testing the app:
+### Global Pytest Fixtures
+
+All global fixtures are defined in `tests/conftest.py`:

 - `local_netrc` - used automatically for all tests and patches the netrc logic to avoid interacting with your system .netrc
 - `local_settings` - used automatically for all tests and patches the global settings path to an isolated directory.
 - `test_settings` - returns a `wandb.Settings` object that can be used to initialize runs against the `live_mock_server`.  See `tests/wandb_integration_test.py`
 - `runner` — exposes a click.CliRunner object which can be used by calling `.isolated_filesystem()`.  This also mocks out calls for login returning a dummy api key.
 - `mocked_run` - returns a mocked out run object that replaces the backend interface with a MagicMock so no actual api calls are made.
+- `mocked_module` - if you need to test code that calls `wandb.util.get_module("XXX")`, you can use this fixture to get a MagicMock().  See `tests/test_notebook.py`
 - `wandb_init_run` - returns a fully functioning run with a mocked out interface (the result of calling wandb.init).  No api's are actually called, but you can access what apis were called via `run._backend.{summary,history,files}`.  See `test/utils/mock_backend.py` and `tests/frameworks/test_keras.py`
 - `mock_server` - mocks all calls to the `requests` module with sane defaults.  You can customize `tests/utils/mock_server.py` to use context or add api calls.
 - `live_mock_server` - we start a live flask server when tests start.  live_mock_server configures WANDB_BASE_URL point to this server.  You can alter or get it's context with the `get_ctx` and `set_ctx` methods.  See `tests/wandb_integration_test.py`.  NOTE: this currently doesn't support concurrent requests so if we run tests in parallel we need to solve for this.
 - `git_repo` — places the test context into an isolated git repository
 - `test_dir` - places the test into `tests/logs/NAME_OF_TEST` this is useful for looking at debug logs.  This is used by `test_settings`
 - `notebook` — gives you a context manager for reading a notebook providing `execute_cell`.  See `tests/utils/notebook_client.py` and `tests/test_notebooks.py`.  This uses `live_mock_server` to enable actual api calls in a notebook context.
+- `mocked_ipython` - to get credit for codecov you may need to pretend you're in a jupyter notebook when you aren't, this fixture enables that.
+
+### Code Coverage
+
+We use codecov to ensure we're executing all branches of logic in our tests.  Below are some JHR Protips™
+
+1. If you want to see the lines not covered you click on the “Diff” tab.   then look for any “+” lines that have a red block for the line number
+2. If you want more context about the files, go to the “Files” tab, it will highlight diffs but you have to do even more searching for the lines you might care about
+3. If you dont want to use codecov, you can use local coverage (i tend to do this for speeding things up a bit, run your tests then run tox -e cover ).   This will give you the old school text output of missing lines (but not based on a diff from master)
+
+We currently have 4 categories of test coverage:
+
+1. project: main coverage numbers, i dont think it can drop by more than a few percent or you will get a failure
+2. patch/tests: must be 100%, if you are writing code for tests, it needs to be executed, if you are planning for the future, comment out your lines
+3. patch/sdk: anything that matches wandb/sdk/*.py (so top level sdk files).   These have lots of ways to test, so it should be high coverage.  currently target is ~80% (but it is dynamic)
+4. patch/other: everything else, we have lots of stuff that isnt easy to test, so it is in this category, currently the requirement is ~60%
+
+I plan on adding more categories, as we get some of these tests and fixtures improved:
+
+1. patch/internal: should be covered very high, the trick is all the error handling cases which I am working on
+2. patch/api: we have no good fixtures for this, so until we do, this will get a waiver
+3. patch/sdk-other: will be a catch all for other stuff in wandb/sdk/,  so patch/other will be stuff outside sdk
+
+### Regression Testing
+
+TODO(jhr): describe how regression works, how to run them, where they're located etc.

 ## Live development

@@ -233,7 +299,7 @@ User Process:
 Internal Process:

 - When ConfigData message is seen, queue message to wandb_write and wandb_send
-- wandb_send thread sends upsert_run grapql http request
+- wandb_send thread sends upsert_run graphql http request

 #### wandb.log()

@@ -246,7 +312,7 @@ Internal Process:
 - When HistoryData message is seen, queue message to wandb_write and wandb_send
 - wandb_send thread sends file_stream data to cloud server

-#### end of program or wandb.join()
+#### end of program or wandb.finish()

 User process:

diff --git a/tests/conftest.py b/tests/conftest.py
index c4b65105..c0e70e3a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,6 +22,7 @@ import git
 import psutil
 import atexit
 import wandb
+import shutil
 from wandb.util import mkdir_exists_ok
 from six.moves import urllib

@@ -54,6 +55,7 @@ server = None

 def test_cleanup(*args, **kwargs):
     global server
+    print("Shutting down mock server")
     server.terminate()
     print("Open files during tests: ")
     proc = psutil.Process()
@@ -306,38 +308,82 @@ def live_mock_server(request):


 @pytest.fixture
-def notebook(live_mock_server):
+def notebook(live_mock_server, test_dir):
     """This launches a live server, configures a notebook to use it, and enables
     devs to execute arbitrary cells.  See tests/test_notebooks.py
-
-    TODO: we should launch a single server on boot and namespace requests by host"""
+    """

     @contextmanager
-    def notebook_loader(nb_path, kernel_name="wandb_python", **kwargs):
+    def notebook_loader(nb_path, kernel_name="wandb_python", save_code=True, **kwargs):
         with open(utils.notebook_path("setup.ipynb")) as f:
             setupnb = nbformat.read(f, as_version=4)
             setupcell = setupnb["cells"][0]
             # Ensure the notebooks talks to our mock server
             new_source = setupcell["source"].replace(
-                "__WANDB_BASE_URL__", live_mock_server.base_url
+                "__WANDB_BASE_URL__", live_mock_server.base_url,
             )
+            if save_code:
+                new_source = new_source.replace("__WANDB_NOTEBOOK_NAME__", nb_path)
+            else:
+                new_source = new_source.replace("__WANDB_NOTEBOOK_NAME__", "")
             setupcell["source"] = new_source

-        with open(utils.notebook_path(nb_path)) as f:
+        nb_path = utils.notebook_path(nb_path)
+        shutil.copy(nb_path, os.path.join(os.getcwd(), os.path.basename(nb_path)))
+        with open(nb_path) as f:
             nb = nbformat.read(f, as_version=4)
         nb["cells"].insert(0, setupcell)

-        client = utils.WandbNotebookClient(nb)
-        with client.setup_kernel(**kwargs):
-            # Run setup commands for mocks
-            client.execute_cell(0, store_history=False)
-            yield client
+        try:
+            client = utils.WandbNotebookClient(nb, kernel_name=kernel_name)
+            with client.setup_kernel(**kwargs):
+                # Run setup commands for mocks
+                client.execute_cells(-1, store_history=False)
+                yield client
+        finally:
+            with open(os.path.join(os.getcwd(), "notebook.log"), "w") as f:
+                f.write(client.all_output_text())
+            wandb.termlog("Find debug logs at: %s" % os.getcwd())
+            wandb.termlog(client.all_output_text())

     notebook_loader.base_url = live_mock_server.base_url

     return notebook_loader


+@pytest.fixture
+def mocked_module(monkeypatch):
+    """This allows us to mock modules loaded via wandb.util.get_module"""
+
+    def mock_get_module(module):
+        orig_get_module = wandb.util.get_module
+        mocked_module = MagicMock()
+
+        def get_module(mod):
+            if mod == module:
+                return mocked_module
+            else:
+                return orig_get_module(mod)
+
+        monkeypatch.setattr(wandb.util, "get_module", get_module)
+        return mocked_module
+
+    return mock_get_module
+
+
+@pytest.fixture
+def mocked_ipython(monkeypatch):
+    monkeypatch.setattr(
+        wandb.wandb_sdk.wandb_settings, "_get_python_type", lambda: "jupyter"
+    )
+    ipython = MagicMock()
+    # TODO: this is really unfortunate, for reasons not clear to me, monkeypatch doesn't work
+    orig_get_ipython = wandb.jupyter.get_ipython
+    wandb.jupyter.get_ipython = lambda: ipython
+    yield ipython
+    wandb.jupyter.get_ipython = orig_get_ipython
+
+
 def default_wandb_args():
     """This allows us to parameterize the wandb_init_run fixture
     The most general arg is "env", you can call:
diff --git a/tests/notebooks/code_saving.ipynb b/tests/notebooks/code_saving.ipynb
new file mode 100644
index 00000000..0e531c74
--- /dev/null
+++ b/tests/notebooks/code_saving.ipynb
@@ -0,0 +1,57 @@
+{
+ "metadata": {
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": 3
+  },
+  "orig_nbformat": 2
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2,
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import wandb"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "wandb.init(project=\"code_save\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"Running some code\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "wandb.finish()"
+   ]
+  }
+ ]
+}
\ No newline at end of file
diff --git a/tests/notebooks/setup.ipynb b/tests/notebooks/setup.ipynb
index 91013e92..e2d9d302 100644
--- a/tests/notebooks/setup.ipynb
+++ b/tests/notebooks/setup.ipynb
@@ -8,9 +8,9 @@
    "source": [
     "\"\"\"This sets up our testing environment.  Currently only the first cell of this notebook is run\"\"\"\n",
     "import os\n",
-    "\n",
     "os.environ[\"WANDB_API_KEY\"] = \"1824812581259009ca9981580f8f8a9012409eee\"\n",
-    "os.environ[\"WANDB_BASE_URL\"] = \"__WANDB_BASE_URL__\""
+    "os.environ[\"WANDB_BASE_URL\"] = \"__WANDB_BASE_URL__\"\n",
+    "os.environ[\"WANDB_NOTEBOOK_NAME\"] = \"__WANDB_NOTEBOOK_NAME__\""
    ]
   }
  ],
diff --git a/tests/test_library_public.py b/tests/test_library_public.py
index d53b747b..715c0177 100644
--- a/tests/test_library_public.py
+++ b/tests/test_library_public.py
@@ -157,6 +157,7 @@ SYMBOLS_RUN = {
     "upsert_artifact",
     "finish_artifact",
     "use_artifact",
+    "log_code",
     "alert",
     # "summary",   # really this should be here
     # mode stuff
diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py
index 571d210c..b77f103f 100644
--- a/tests/test_notebooks.py
+++ b/tests/test_notebooks.py
@@ -1,7 +1,8 @@
-import sys
+import os
 import platform
 import pytest
-
+import sys
+import wandb

 pytestmark = pytest.mark.skipif(
     sys.version_info < (3, 5) or platform.system() == "Windows",
@@ -11,8 +12,8 @@ pytestmark = pytest.mark.skipif(

 def test_one_cell(notebook):
     with notebook("one_cell.ipynb") as nb:
-        nb.execute_cell(cell_index=1)
-        output = nb.cell_output(1)
+        nb.execute_all()
+        output = nb.cell_output(0)
         print(output)
         assert "lovely-dawn-32" in output[-1]["data"]["text/html"]
         # assert "Failed to query for notebook name" not in text
@@ -20,7 +21,84 @@ def test_one_cell(notebook):

 def test_magic(notebook):
     with notebook("magic.ipynb") as nb:
-        nb.execute_cell(cell_index=[1, 2])
-        output = nb.cell_output(2)
+        nb.execute_all()
+        output = nb.cell_output(1)
         print(output)
         assert notebook.base_url in output[0]["data"]["text/html"]
+
+
+def test_code_saving(notebook, live_mock_server):
+    # TODO: this is awfully slow, we should likely run these in parallel
+    with notebook("code_saving.ipynb") as nb:
+        nb.execute_all()
+        server_ctx = live_mock_server.get_ctx()
+        artifact_name = list(server_ctx["artifacts"].keys())[0]
+        # We run 3 cells after calling wandb.init
+        assert len(server_ctx["artifacts"][artifact_name]) == 3
+
+    with notebook("code_saving.ipynb", save_code=False) as nb:
+        nb.execute_all()
+        assert "Failed to detect the name of this notebook" in nb.all_output_text()
+
+    # Let's make sure we warn the user if they lie to us.
+    with notebook("code_saving.ipynb") as nb:
+        os.remove("code_saving.ipynb")
+        nb.execute_all()
+        assert "WANDB_NOTEBOOK_NAME should be a path" in nb.all_output_text()
+
+
+def test_notebook_not_exists(mocked_ipython, live_mock_server, capsys, test_settings):
+    os.environ["WANDB_NOTEBOOK_NAME"] = "fake.ipynb"
+    wandb.init(settings=test_settings)
+    _, err = capsys.readouterr()
+    assert "WANDB_NOTEBOOK_NAME should be a path" in err
+    del os.environ["WANDB_NOTEBOOK_NAME"]
+
+
+def test_notebook_metadata_jupyter(mocker, mocked_module, live_mock_server):
+    ipyconnect = mocker.patch("ipykernel.connect")
+    ipyconnect.get_connection_file.return_value = "kernel-12345.json"
+    serverapp = mocked_module("jupyter_server.serverapp")
+    serverapp.list_running_servers.return_value = [
+        {"url": live_mock_server.base_url, "notebook_dir": "/test"}
+    ]
+    meta = wandb.jupyter.notebook_metadata(False)
+    assert meta == {"path": "test.ipynb", "root": "/test", "name": "test.ipynb"}
+
+
+def test_notebook_metadata_no_servers(mocker, mocked_module):
+    ipyconnect = mocker.patch("ipykernel.connect")
+    ipyconnect.get_connection_file.return_value = "kernel-12345.json"
+    serverapp = mocked_module("jupyter_server.serverapp")
+    serverapp.list_running_servers.return_value = []
+    meta = wandb.jupyter.notebook_metadata(False)
+    assert meta == {}
+
+
+def test_notebook_metadata_colab(mocked_module):
+    colab = mocked_module("google.colab")
+    colab._message.blocking_request.return_value = {
+        "ipynb": {"metadata": {"colab": {"name": "colab.ipynb"}}}
+    }
+    meta = wandb.jupyter.notebook_metadata(False)
+    assert meta == {
+        "root": "/content",
+        "path": "colab.ipynb",
+        "name": "colab.ipynb",
+    }
+
+
+def test_notebook_metadata_kaggle(mocker, mocked_module):
+    os.environ["KAGGLE_KERNEL_RUN_TYPE"] = "test"
+    kaggle = mocked_module("kaggle_session")
+    kaggle_client = mocker.MagicMock()
+    kaggle_client.get_exportable_ipynb.return_value = {
+        "metadata": {"name": "kaggle.ipynb"}
+    }
+    kaggle.UserSessionClient.return_value = kaggle_client
+    meta = wandb.jupyter.notebook_metadata(False)
+    assert meta == {
+        "root": "/kaggle/working",
+        "path": "kaggle.ipynb",
+        "name": "kaggle.ipynb",
+    }
diff --git a/tests/utils/mock_server.py b/tests/utils/mock_server.py
index 98914460..49949703 100644
--- a/tests/utils/mock_server.py
+++ b/tests/utils/mock_server.py
@@ -119,12 +119,12 @@ def run(ctx):
     }


-def artifact(ctx, collection_name="mnist"):
+def artifact(ctx, collection_name="mnist", state="COMMITTED"):
     return {
         "id": ctx["page_count"],
         "digest": "abc123",
         "description": "",
-        "state": "COMMITTED",
+        "state": state,
         "size": 10000,
         "createdAt": datetime.now().isoformat(),
         "updatedAt": datetime.now().isoformat(),
@@ -523,6 +523,11 @@ def create_app(user_ctx=None):
             return json.dumps({"data": {"prepareFiles": {"files": {"edges": nodes}}}})
         if "mutation CreateArtifact(" in body["query"]:
             collection_name = body["variables"]["artifactCollectionNames"][0]
+            ctx["artifacts"] = ctx.get("artifacts", {})
+            ctx["artifacts"][collection_name] = ctx["artifacts"].get(
+                collection_name, []
+            )
+            ctx["artifacts"][collection_name].append(body["variables"])
             return {
                 "data": {"createArtifact": {"artifact": artifact(ctx, collection_name)}}
             }
@@ -601,7 +606,11 @@ def create_app(user_ctx=None):
             }
         if "query Artifact(" in body["query"]:
             art = artifact(ctx)
-            art["artifactType"] = {"id": 1, "name": "dataset"}
+            # code artifacts use source-RUNID names, we return the code type
+            if "source" in body["variables"]["name"]:
+                art["artifactType"] = {"id": 2, "name": "code"}
+            else:
+                art["artifactType"] = {"id": 1, "name": "dataset"}
             return {"data": {"project": {"artifact": art}}}
         if "query ArtifactManifest(" in body["query"]:
             art = artifact(ctx)
@@ -776,6 +785,17 @@ index 30d74d2..9a2c773 100644
         else:
             return b"", 500

+    @app.route("/api/sessions")
+    def jupyter_sessions():
+        return json.dumps(
+            [
+                {
+                    "kernel": {"id": "12345"},
+                    "notebook": {"path": "test.ipynb", "name": "test.ipynb"},
+                }
+            ]
+        )
+
     @app.route("/pypi/<library>/json")
     def pypi(library):
         version = getattr(wandb, "__hack_pypi_latest_version__", wandb.__version__)
diff --git a/tests/utils/notebook_client.py b/tests/utils/notebook_client.py
index 2216859c..97caf8dd 100644
--- a/tests/utils/notebook_client.py
+++ b/tests/utils/notebook_client.py
@@ -6,17 +6,20 @@ except ImportError:  # TODO: no fancy notebook fun in python2


 class WandbNotebookClient(NotebookClient):
-    def execute_cell(self, cell_index=0, execution_count=None, store_history=True):
+    def execute_cells(self, cell_index=0, execution_count=None, store_history=True):
+        """ Execute a specific cell.  Since we always execute setup.py in the first
+            cell we increment the index offset here
+        """
         if not isinstance(cell_index, list):
             cell_index = [cell_index]
         executed_cells = []

         for idx in cell_index:
             try:
-                cell = self.nb["cells"][idx]
+                cell = self.nb["cells"][idx + 1]
                 ecell = super().execute_cell(
                     cell,
-                    idx,
+                    idx + 1,
                     execution_count=execution_count,
                     store_history=store_history,
                 )
@@ -29,11 +32,16 @@ class WandbNotebookClient(NotebookClient):
                 raise e
             for output in ecell["outputs"]:
                 if output["output_type"] == "error":
+                    print("Error in cell: %s" % idx + 1)
+                    print("\n".join(output["traceback"]))
                     raise ValueError(output["evalue"])
             executed_cells.append(ecell)

         return executed_cells

+    def execute_all(self, store_history=True):
+        return self.execute_cells(list(range(len(self.nb["cells"]) - 1)), store_history)
+
     def cell_output_text(self, cell_index):
         """Return cell text output

@@ -45,15 +53,23 @@ class WandbNotebookClient(NotebookClient):
         """

         text = ""
-        outputs = self.nb["cells"][cell_index]["outputs"]
+        outputs = self.nb["cells"][cell_index + 1]["outputs"]
         for output in outputs:
             if "text" in output:
                 text += output["text"]

         return text

+    def all_output_text(self):
+        text = ""
+        for i in range(len(self.nb["cells"]) - 1):
+            text += self.cell_output_text(i)
+        return text
+
     def cell_output(self, cell_index):
-        """Return cell text output
+        """Return a cells outputs
+
+        NOTE: Since we always execute an init cell we adjust the offset by 1

         Arguments:
             cell_index {int} -- cell index in notebook
@@ -62,5 +78,5 @@ class WandbNotebookClient(NotebookClient):
             list -- List of outputs for the given cell
         """

-        outputs = self.nb["cells"][cell_index]["outputs"]
+        outputs = self.nb["cells"][cell_index + 1]["outputs"]
         return outputs
diff --git a/tests/utils/utils.py b/tests/utils/utils.py
index 1aa2e04d..a989a777 100644
--- a/tests/utils/utils.py
+++ b/tests/utils/utils.py
@@ -18,8 +18,8 @@ def fixture_open(path):

 def notebook_path(path):
     """Returns the path to a notebook"""
-    return os.path.join(
-        os.path.dirname(os.path.abspath(__file__)), "..", "notebooks", path
+    return os.path.abspath(
+        os.path.join(os.path.dirname(__file__), "..", "notebooks", path)
     )


diff --git a/tests/wandb_run_test.py b/tests/wandb_run_test.py
index 654c9fa9..4f3441cb 100644
--- a/tests/wandb_run_test.py
+++ b/tests/wandb_run_test.py
@@ -2,6 +2,7 @@
 config tests.
 """

+import os
 import sys
 import pytest
 import yaml
@@ -56,3 +57,35 @@ def test_run_pub_history(fake_run, record_q, records_util):
     history = r.history
     assert len(history) == 2
     # TODO(jhr): check history vals
+
+
+def test_log_code(test_settings):
+    run = wandb.init(mode="offline", settings=test_settings)
+    with open("test.py", "w") as f:
+        f.write('print("test")')
+    with open("big_file.h5", "w") as f:
+        f.write("Not that big")
+    art = run.log_code()
+    assert sorted(art.manifest.entries.keys()) == ["test.py"]
+
+
+def test_log_code_include(test_settings):
+    run = wandb.init(mode="offline", settings=test_settings)
+    with open("test.py", "w") as f:
+        f.write('print("test")')
+    with open("test.cc", "w") as f:
+        f.write("Not that big")
+    art = run.log_code(include_fn=lambda p: p.endswith(".py") or p.endswith(".cc"))
+    assert sorted(art.manifest.entries.keys()) == ["test.cc", "test.py"]
+
+
+def test_log_code_custom_root(test_settings):
+    with open("test.py", "w") as f:
+        f.write('print("test")')
+    os.mkdir("custom")
+    os.chdir("custom")
+    with open("test.py", "w") as f:
+        f.write('print("test")')
+    run = wandb.init(mode="offline", settings=test_settings)
+    art = run.log_code(root="../")
+    assert sorted(art.manifest.entries.keys()) == ["custom/test.py", "test.py"]
diff --git a/tox.ini b/tox.ini
index c89dcb74..ebf0f179 100644
--- a/tox.ini
+++ b/tox.ini
@@ -16,7 +16,7 @@ deps =
     pytest-flakefinder
 passenv = USERNAME
 setenv =
-    py{27,35,36,37,38}: COVERAGE_FILE={envdir}/.coverage
+    py{27,35,36,37,38,39}: COVERAGE_FILE={envdir}/.coverage
     py{37}: WINDIR=C:\\Windows
 # Pytorch installations on non-darwin need the `-f`
 whitelist_externals =
@@ -25,7 +25,7 @@ whitelist_externals =
 commands_pre =
     py{36,37,38}: pip install fastparquet
 commands =
-    py{35,36,37,38}: ipython kernel install --user --name=wandb_python
+    py{35,36,37,38,39}: ipython kernel install --user --name=wandb_python
     mkdir -p test-results
     python -m pytest --junitxml=test-results/junit.xml --cov-config=tox.ini --cov=wandb --cov=tests/ --cov-report= --no-cov-on-fail --ignore=wandb/sweeps --ignore=build/ {posargs:tests/ wandb/sweeps/}

diff --git a/wandb/cli/cli.py b/wandb/cli/cli.py
index 83172abd..ecc18121 100644
--- a/wandb/cli/cli.py
+++ b/wandb/cli/cli.py
@@ -233,7 +233,7 @@ def login(key, host, cloud, relogin, anonymously, no_offline=False):
         # force relogin if host is specified
         _api.set_setting("base_url", host, globally=True, persist=True)
     key = key[0] if len(key) > 0 else None
-    if host or cloud or key:
+    if key:
         relogin = True

     wandb.setup(
diff --git a/wandb/jupyter.py b/wandb/jupyter.py
index f96d61be..2512f540 100644
--- a/wandb/jupyter.py
+++ b/wandb/jupyter.py
@@ -1,7 +1,9 @@
 from base64 import b64encode
+import json
 import logging
 import os
 import re
+import shutil
 import sys

 import requests
@@ -77,49 +79,112 @@ class WandBMagics(Magics):


 def notebook_metadata(silent):
-    """Attempts to query jupyter for the path and name of the notebook file"""
+    """Attempts to query jupyter for the path and name of the notebook file.
+
+    This can handle many different jupyter environments, specifically:
+
+    1. Colab
+    2. Kaggle
+    3. JupyterLab
+    4. Notebooks
+    5. Other?
+    """
     error_message = (
-        "Failed to query for notebook name, you can set it manually with "
-        "the WANDB_NOTEBOOK_NAME environment variable"
+        "Failed to detect the name of this notebook, you can set it manually with "
+        "the WANDB_NOTEBOOK_NAME environment variable to enable code saving."
     )
     try:
-        import ipykernel
-        from notebook.notebookapp import list_running_servers
+        # In colab we can request the most recent contents
+        ipynb = attempt_colab_load_ipynb()
+        if ipynb:
+            return {
+                "root": "/content",
+                "path": ipynb["metadata"]["colab"]["name"],
+                "name": ipynb["metadata"]["colab"]["name"],
+            }

-        kernel_id = re.search(
-            "kernel-(.*).json", ipykernel.connect.get_connection_file()
-        ).group(1)
-        servers = list(
-            list_running_servers()
-        )  # TODO: sometimes there are invalid JSON files and this blows up
+        if wandb.util._is_kaggle():
+            # In kaggle we can request the most recent contents
+            ipynb = attempt_kaggle_load_ipynb()
+            if ipynb:
+                return {
+                    "root": "/kaggle/working",
+                    "path": ipynb["metadata"]["name"],
+                    "name": ipynb["metadata"]["name"],
+                }
+
+        servers, kernel_id = jupyter_servers_and_kernel_id()
+        for s in servers:
+            if s.get("password"):
+                raise ValueError("Can't query password protected kernel")
+            res = requests.get(
+                urljoin(s["url"], "api/sessions"), params={"token": s.get("token", "")}
+            ).json()
+            for nn in res:
+                # TODO: wandb/client#400 found a case where res returned an array of
+                # strings...
+                if isinstance(nn, dict) and nn.get("kernel") and "notebook" in nn:
+                    if nn["kernel"]["id"] == kernel_id:
+                        return {
+                            "root": s.get(
+                                "root_dir", s.get("notebook_dir", os.getcwd())
+                            ),
+                            "path": nn["notebook"]["path"],
+                            "name": nn["notebook"]["name"],
+                        }
+
+        if not silent:
+            logger.error(error_message)
+        return {}
     except Exception:
+        # TODO: report this exception
         # TODO: Fix issue this is not the logger initialized in in wandb.init()
         # since logger is not attached, outputs to notebook
         if not silent:
             logger.error(error_message)
         return {}
-    for s in servers:
+
+
+def jupyter_servers_and_kernel_id():
+    """Returns a list of servers and the current kernel_id so we can query for
+    the name of the notebook"""
+    try:
+        import ipykernel
+
+        kernel_id = re.search(
+            "kernel-(.*).json", ipykernel.connect.get_connection_file()
+        ).group(1)
+        # We're either in jupyterlab or a notebook, lets prefer the newer jupyter_server package
+        serverapp = wandb.util.get_module("jupyter_server.serverapp")
+        notebookapp = wandb.util.get_module("notebook.notebookapp")
+        servers = []
+        if serverapp is not None:
+            servers.extend(list(serverapp.list_running_servers()))
+        if notebookapp is not None:
+            servers.extend(list(notebookapp.list_running_servers()))
+        return servers, kernel_id
+    except (AttributeError, ValueError, ImportError):
+        return [], None
+
+
+def attempt_colab_load_ipynb():
+    colab = wandb.util.get_module("google.colab")
+    if colab:
+        # This isn't thread safe, never call in a thread
+        response = colab._message.blocking_request("get_ipynb", timeout_sec=5)
+        if response:
+            return response["ipynb"]
+
+
+def attempt_kaggle_load_ipynb():
+    kaggle = wandb.util.get_module("kaggle_session")
+    if kaggle:
         try:
-            if s["password"]:
-                raise ValueError("Can't query password protected kernel")
-            res = requests.get(
-                urljoin(s["url"], "api/sessions"), params={"token": s.get("token", "")}
-            ).json()
-        except (requests.RequestException, ValueError):
-            if not silent:
-                logger.error(error_message)
-            return {}
-        for nn in res:
-            # TODO: wandb/client#400 found a case where res returned an array of
-            # strings...
-            if isinstance(nn, dict) and nn.get("kernel") and "notebook" in nn:
-                if nn["kernel"]["id"] == kernel_id:
-                    return {
-                        "root": s["notebook_dir"],
-                        "path": nn["notebook"]["path"],
-                        "name": nn["notebook"]["name"],
-                    }
-    return {}
+            client = kaggle.UserSessionClient()
+            return client.get_exportable_ipynb()
+        except Exception:
+            logger.exception("Unable to load kaggle notebook")
+            return None


 def attempt_colab_login(app_url):
@@ -192,6 +257,45 @@ class Notebook(object):
             {"data": b64_data, "metadata": data_with_metadata["metadata"]}
         )

+    def save_ipynb(self):
+        if not self.settings.save_code:
+            logger.info("not saving jupyter notebook")
+            return False
+        relpath = self.settings._jupyter_path
+        logger.info("looking for notebook: %s", relpath)
+        if relpath:
+            if os.path.exists(relpath):
+                shutil.copy(
+                    relpath,
+                    os.path.join(self.settings._tmp_code_dir, os.path.basename(relpath)),
+                )
+                return True
+
+        # TODO: likely only save if the code has changed
+        colab_ipynb = attempt_colab_load_ipynb()
+        if colab_ipynb:
+            with open(
+                os.path.join(
+                    self.settings._tmp_code_dir, colab_ipynb["metadata"]["colab"]["name"]
+                ),
+                "w",
+                encoding="utf-8",
+            ) as f:
+                f.write(json.dumps(colab_ipynb))
+            return True
+
+        kaggle_ipynb = attempt_kaggle_load_ipynb()
+        if kaggle_ipynb and len(kaggle_ipynb["cells"]) > 0:
+            with open(
+                os.path.join(self.settings._tmp_code_dir, kaggle_ipynb["metadata"]["name"]),
+                "w",
+                encoding="utf-8",
+            ) as f:
+                f.write(json.dumps(kaggle_ipynb))
+            return True
+
+        return False
+
     def save_history(self):
         """This saves all cell executions in the current session as a new notebook"""
         try:
@@ -250,6 +354,12 @@ class Notebook(object):
             wandb.run.config["_wandb"]["session_history"] = state_path
             wandb.run.config.persist()
             wandb.util.mkdir_exists_ok(os.path.join(wandb.run.dir, "code"))
+            with open(
+                os.path.join(self.settings._tmp_code_dir, "_session_history.ipynb"),
+                "w",
+                encoding="utf-8",
+            ) as f:
+                write(nb, f, version=4)
             with open(
                 os.path.join(wandb.run.dir, state_path), "w", encoding="utf-8"
             ) as f:
diff --git a/wandb/sdk/lib/filenames.py b/wandb/sdk/lib/filenames.py
index 23fd479b..00b46e8a 100644
--- a/wandb/sdk/lib/filenames.py
+++ b/wandb/sdk/lib/filenames.py
@@ -1,4 +1,12 @@
 #
+import os
+
+import wandb
+
+if wandb.TYPE_CHECKING:  # type: ignore
+    from typing import Callable
+
+
 CONFIG_FNAME = "config.yaml"
 OUTPUT_FNAME = "output.log"
 DIFF_FNAME = "diff.patch"
@@ -19,3 +27,14 @@ def is_wandb_file(name):
         or name == OUTPUT_FNAME
         or name == DIFF_FNAME
     )
+
+
+def filtered_dir(
+    root: str, include_fn: Callable[[str], bool], exclude_fn: Callable[[str], bool]
+):
+    """Simple generator to walk a directory"""
+    for dirpath, _, files in os.walk(root):
+        for fname in files:
+            file_path = os.path.join(dirpath, fname)
+            if include_fn(file_path) and not exclude_fn(file_path):
+                yield file_path
diff --git a/wandb/sdk/lib/ipython.py b/wandb/sdk/lib/ipython.py
index 3eabd69b..290ba493 100644
--- a/wandb/sdk/lib/ipython.py
+++ b/wandb/sdk/lib/ipython.py
@@ -16,11 +16,13 @@ STYLED_TABLE_HTML = """<style>
 def _get_python_type():
     try:
         from IPython import get_ipython  # type: ignore
+
+        # Calling get_ipython can cause an ImportError
+        if get_ipython() is None:
+            return "python"
     except ImportError:
         return "python"
-    if get_ipython() is None:
-        return "python"
-    elif "terminal" in get_ipython().__module__ or "spyder" in sys.modules:
+    if "terminal" in get_ipython().__module__ or "spyder" in sys.modules:
         return "ipython"
     else:
         return "jupyter"
diff --git a/wandb/sdk/lib/server.py b/wandb/sdk/lib/server.py
index 9fdb5c7e..97abde90 100644
--- a/wandb/sdk/lib/server.py
+++ b/wandb/sdk/lib/server.py
@@ -7,7 +7,6 @@ import json

 from wandb import util
 from wandb.apis import InternalApi
-from wandb.errors.error import CommError


 class ServerError(Exception):
@@ -34,10 +33,6 @@ class Server(object):
             self._error_network = True
             return
         if viewer_thread.is_alive():
-            if util._is_kaggle():
-                raise CommError(
-                    "To use W&B in kaggle you must enable internet in the settings panel on the right."  # noqa: E501
-                )
             # this is likely a DNS hang
             self._error_network = True
             return
diff --git a/wandb/sdk/wandb_init.py b/wandb/sdk/wandb_init.py
index 522b9793..78a4c0f3 100644
--- a/wandb/sdk/wandb_init.py
+++ b/wandb/sdk/wandb_init.py
@@ -165,11 +165,11 @@ class _WandbInit(object):
         d = dict(_start_time=time.time(), _start_datetime=datetime.datetime.now(),)
         settings.update(d)

+        self._log_setup(settings)
+
         if settings._jupyter:
             self._jupyter_setup(settings)

-        self._log_setup(settings)
-
         self.settings = settings.freeze()

     def teardown(self):
@@ -238,6 +238,10 @@ class _WandbInit(object):
     def _pause_backend(self):
         if self.backend is not None:
             logger.info("pausing backend")
+            # Attempt to save the code on every execution
+            if self.notebook.save_ipynb():
+                res = self.run.log_code(root=None)
+                logger.info("saved code: %s", res)
             self.backend.interface.publish_pause()

     def _resume_backend(self):
@@ -247,9 +251,12 @@ class _WandbInit(object):

     def _jupyter_teardown(self):
         """Teardown hooks and display saving, called with wandb.finish"""
-        logger.info("cleaning up jupyter logic")
         ipython = self.notebook.shell
         self.notebook.save_history()
+        if self.notebook.save_ipynb():
+            self.run.log_code(root=None)
+            logger.info("saved code and history")
+        logger.info("cleaning up jupyter logic")
         # because of how we bind our methods we manually find them to unregister
         for hook in ipython.events.callbacks["pre_run_cell"]:
             if "_resume_backend" in hook.__name__:
@@ -291,6 +298,7 @@ class _WandbInit(object):
         filesystem._safe_makedirs(os.path.dirname(settings.log_internal))
         filesystem._safe_makedirs(os.path.dirname(settings.sync_file))
         filesystem._safe_makedirs(settings.files_dir)
+        filesystem._safe_makedirs(settings._tmp_code_dir)

         if settings.symlink:
             self._safe_symlink(
diff --git a/wandb/sdk/wandb_login.py b/wandb/sdk/wandb_login.py
index 583a5541..2b8c8a29 100644
--- a/wandb/sdk/wandb_login.py
+++ b/wandb/sdk/wandb_login.py
@@ -188,6 +188,11 @@ def _login(

     if wlogin._settings._offline:
         return False
+    elif wandb.util._is_kaggle() and not wandb.util._has_internet():
+        wandb.termerror(
+            "To use W&B in kaggle you must enable internet in the settings panel on the right."
+        )
+        return False

     # perform a login
     logged_in = wlogin.login()
diff --git a/wandb/sdk/wandb_run.py b/wandb/sdk/wandb_run.py
index 25dd598d..e28bf251 100644
--- a/wandb/sdk/wandb_run.py
+++ b/wandb/sdk/wandb_run.py
@@ -76,6 +76,7 @@ if wandb.TYPE_CHECKING:  # type: ignore
         PollExitResponse,
     )
     from .wandb_setup import _WandbSetup
+    from .wandb_artifacts import Artifact
     from wandb.apis.public import Api as PublicApi

     from typing import TYPE_CHECKING
@@ -601,6 +602,66 @@ class Run(object):
         """
         return self.project_name()

+    def log_code(
+        self,
+        root: str = ".",
+        name: str = None,
+        include_fn: Callable[[str], bool] = lambda path: path.endswith(".py"),
+        exclude_fn: Callable[[str], bool] = lambda path: os.sep + "wandb" + os.sep
+        in path,
+    ) -> Optional[Artifact]:
+        """
+        log_code() saves the current state of your code to a W&B artifact.  By
+        default it walks the current directory and logs all files that end with ".py".
+
+        Arguments:
+            root (str, optional): The relative (to os.getcwd()) or absolute path to
+                recursively find code from.
+            name (str, optional): The name of our code artifact.  By default we'll name
+                the artifact "source-$RUN_ID".  There may be scenarios where you want
+                many runs to share the same artifact.  Specifying name allows you to achieve that.
+            include_fn (callable, optional): A callable that accepts a file path and
+                returns True when it should be included and False otherwise.  This
+                defaults to: `lambda path: path.endswith(".py")`
+            exclude_fn (callable, optional): A callable that accepts a file path and
+                returns True when it should be excluded and False otherwise.  This
+                defaults to: `lambda path: False`
+
+        Examples:
+            Basic usage
+            ```
+            run.log_code()
+            ```
+
+            Advanced usage
+            ```
+            run.log_code("../", include_fn=lambda path: path.endswith(".py") or path.endswith(".ipynb"))
+            ```
+
+        Returns:
+            An `Artifact` object if code was logged
+        """
+        # TODO: should this type be a special wandb type?
+        name = name or "{}-{}".format("source", self.id)
+        art = wandb.Artifact(name, "code")
+        files_added = False
+        if root is not None:
+            root = os.path.abspath(root)
+            for file_path in filenames.filtered_dir(root, include_fn, exclude_fn):
+                files_added = True
+                save_name = os.path.relpath(file_path, root)
+                art.add_file(file_path, name=save_name)
+        # Add any manually staged files such is ipynb notebooks
+        for dirpath, _, files in os.walk(self._settings._tmp_code_dir):
+            for fname in files:
+                file_path = os.path.join(dirpath, fname)
+                save_name = os.path.relpath(file_path, self._settings._tmp_code_dir)
+                files_added = True
+                art.add_file(file_path, name=save_name)
+        if not files_added:
+            return None
+        return self.log_artifact(art)
+
     def get_url(self) -> Optional[str]:
         """
         Returns:
@@ -1436,16 +1497,16 @@ class Run(object):

     def _on_start(self) -> None:
         # TODO: make offline mode in jupyter use HTML
-        if self._settings._offline:
-            wandb.termlog("Offline run mode, not syncing to the cloud.")
-
         if self._settings._offline:
             wandb.termlog(
                 (
                     "W&B syncing is set to `offline` in this directory.  "
-                    "Run `wandb online` to enable cloud syncing."
+                    "Run `wandb online` or set WANDB_MODE=online to enable cloud syncing."
                 )
             )
+        print("GOT SETTINGS", self._settings.code_dir)
+        if self._settings.code_dir is not None:
+            self.log_code(self._settings.code_dir)
         if self._run_obj and not self._settings._silent:
             self._display_run()
         if self._backend and not self._settings._offline:
diff --git a/wandb/sdk/wandb_settings.py b/wandb/sdk/wandb_settings.py
index c76a322e..9995db47 100644
--- a/wandb/sdk/wandb_settings.py
+++ b/wandb/sdk/wandb_settings.py
@@ -85,6 +85,7 @@ env_settings: Dict[str, Optional[str]] = dict(
     host=None,
     username=None,
     disable_code=None,
+    code_dir=None,
     anonymous=None,
     ignore_globs=None,
     resume=None,
@@ -215,6 +216,7 @@ class Settings(object):
     sync_file_spec: Optional[str] = None
     sync_dir_spec: Optional[str] = None
     files_dir_spec: Optional[str] = None
+    tmp_dir_spec: Optional[str] = None
     log_symlink_user_spec: Optional[str] = None
     log_symlink_internal_spec: Optional[str] = None
     sync_symlink_latest_spec: Optional[str] = None
@@ -226,6 +228,7 @@ class Settings(object):
     show_errors: bool = True
     email: Optional[str] = None
     save_code: Optional[bool] = None
+    code_dir: Optional[str] = None
     program_relpath: Optional[str] = None
     host: Optional[str]

@@ -318,9 +321,11 @@ class Settings(object):
         log_symlink_internal_spec="{wandb_dir}/debug-internal.log",
         resume_fname_spec="{wandb_dir}/wandb-resume.json",
         files_dir_spec="{wandb_dir}/{run_mode}-{timespec}-{run_id}/files",
+        tmp_dir_spec="{wandb_dir}/{run_mode}-{timespec}-{run_id}/tmp",
         symlink=None,  # probed
         # where files are temporary stored when saving
         # files_dir=None,
+        # code_dir=None,
         # data_base_dir="wandb",
         # data_dir="",
         # data_spec="wandb-{timespec}-{pid}-data.bin",
@@ -331,6 +336,7 @@ class Settings(object):
         disable_code=None,
         ignore_globs=None,
         save_code=None,
+        code_dir=None,
         program_relpath=None,
         git_remote=None,
         dev_prod=None,  # in old settings files, TODO: support?
@@ -506,6 +512,14 @@ class Settings(object):
     def files_dir(self) -> str:
         return self._path_convert(self.files_dir_spec)

+    @property
+    def tmp_dir(self) -> str:
+        return self._path_convert(self.tmp_dir_spec)
+
+    @property
+    def _tmp_code_dir(self) -> str:
+        return os.path.join(self.tmp_dir, "code")
+
     @property
     def log_symlink_user(self) -> str:
         return self._path_convert(self.log_symlink_user_spec)
@@ -786,6 +800,16 @@ class Settings(object):
             u["_jupyter_path"] = meta.get("path")
             u["_jupyter_name"] = meta.get("name")
             u["_jupyter_root"] = meta.get("root")
+        elif self._jupyter and os.path.exists(self.notebook_name):
+            u["_jupyter_path"] = self.notebook_name
+            u["_jupyter_name"] = self.notebook_name
+            u["_jupyter_root"] = os.getcwd()
+        elif self._jupyter:
+            wandb.termwarn(
+                "WANDB_NOTEBOOK_NAME should be a path to a notebook file, couldn't find {}".format(
+                    self.notebook_name
+                )
+            )

         # host and username are populated by env_settings above if their env
         # vars exist -- but if they don't, we'll fill them in here
diff --git a/wandb/sdk_py27/lib/filenames.py b/wandb/sdk_py27/lib/filenames.py
index c05d1e9d..af9f3bc7 100644
--- a/wandb/sdk_py27/lib/filenames.py
+++ b/wandb/sdk_py27/lib/filenames.py
@@ -1,4 +1,12 @@
 # File is generated by: tox -e codemod
+import os
+
+import wandb
+
+if wandb.TYPE_CHECKING:  # type: ignore
+    from typing import Callable
+
+
 CONFIG_FNAME = "config.yaml"
 OUTPUT_FNAME = "output.log"
 DIFF_FNAME = "diff.patch"
@@ -19,3 +27,14 @@ def is_wandb_file(name):
         or name == OUTPUT_FNAME
         or name == DIFF_FNAME
     )
+
+
+def filtered_dir(
+    root, include_fn, exclude_fn
+):
+    """Simple generator to walk a directory"""
+    for dirpath, _, files in os.walk(root):
+        for fname in files:
+            file_path = os.path.join(dirpath, fname)
+            if include_fn(file_path) and not exclude_fn(file_path):
+                yield file_path
diff --git a/wandb/sdk_py27/lib/ipython.py b/wandb/sdk_py27/lib/ipython.py
index 943bbd49..bf0e046a 100644
--- a/wandb/sdk_py27/lib/ipython.py
+++ b/wandb/sdk_py27/lib/ipython.py
@@ -16,11 +16,13 @@ STYLED_TABLE_HTML = """<style>
 def _get_python_type():
     try:
         from IPython import get_ipython  # type: ignore
+
+        # Calling get_ipython can cause an ImportError
+        if get_ipython() is None:
+            return "python"
     except ImportError:
         return "python"
-    if get_ipython() is None:
-        return "python"
-    elif "terminal" in get_ipython().__module__ or "spyder" in sys.modules:
+    if "terminal" in get_ipython().__module__ or "spyder" in sys.modules:
         return "ipython"
     else:
         return "jupyter"
diff --git a/wandb/sdk_py27/lib/server.py b/wandb/sdk_py27/lib/server.py
index 4702a848..0385301d 100644
--- a/wandb/sdk_py27/lib/server.py
+++ b/wandb/sdk_py27/lib/server.py
@@ -7,7 +7,6 @@ import json

 from wandb import util
 from wandb.apis import InternalApi
-from wandb.errors.error import CommError


 class ServerError(Exception):
@@ -34,10 +33,6 @@ class Server(object):
             self._error_network = True
             return
         if viewer_thread.is_alive():
-            if util._is_kaggle():
-                raise CommError(
-                    "To use W&B in kaggle you must enable internet in the settings panel on the right."  # noqa: E501
-                )
             # this is likely a DNS hang
             self._error_network = True
             return
diff --git a/wandb/sdk_py27/wandb_init.py b/wandb/sdk_py27/wandb_init.py
index c7f67c72..02e236b5 100644
--- a/wandb/sdk_py27/wandb_init.py
+++ b/wandb/sdk_py27/wandb_init.py
@@ -165,11 +165,11 @@ class _WandbInit(object):
         d = dict(_start_time=time.time(), _start_datetime=datetime.datetime.now(),)
         settings.update(d)

+        self._log_setup(settings)
+
         if settings._jupyter:
             self._jupyter_setup(settings)

-        self._log_setup(settings)
-
         self.settings = settings.freeze()

     def teardown(self):
@@ -238,6 +238,10 @@ class _WandbInit(object):
     def _pause_backend(self):
         if self.backend is not None:
             logger.info("pausing backend")
+            # Attempt to save the code on every execution
+            if self.notebook.save_ipynb():
+                res = self.run.log_code(root=None)
+                logger.info("saved code: %s", res)
             self.backend.interface.publish_pause()

     def _resume_backend(self):
@@ -247,9 +251,12 @@ class _WandbInit(object):

     def _jupyter_teardown(self):
         """Teardown hooks and display saving, called with wandb.finish"""
-        logger.info("cleaning up jupyter logic")
         ipython = self.notebook.shell
         self.notebook.save_history()
+        if self.notebook.save_ipynb():
+            self.run.log_code(root=None)
+            logger.info("saved code and history")
+        logger.info("cleaning up jupyter logic")
         # because of how we bind our methods we manually find them to unregister
         for hook in ipython.events.callbacks["pre_run_cell"]:
             if "_resume_backend" in hook.__name__:
@@ -291,6 +298,7 @@ class _WandbInit(object):
         filesystem._safe_makedirs(os.path.dirname(settings.log_internal))
         filesystem._safe_makedirs(os.path.dirname(settings.sync_file))
         filesystem._safe_makedirs(settings.files_dir)
+        filesystem._safe_makedirs(settings.code_dir)

         if settings.symlink:
             self._safe_symlink(
diff --git a/wandb/sdk_py27/wandb_login.py b/wandb/sdk_py27/wandb_login.py
index 8dd722cf..a96609d9 100644
--- a/wandb/sdk_py27/wandb_login.py
+++ b/wandb/sdk_py27/wandb_login.py
@@ -188,6 +188,11 @@ def _login(

     if wlogin._settings._offline:
         return False
+    elif wandb.util._is_kaggle() and not wandb.util._has_internet():
+        wandb.termerror(
+            "To use W&B in kaggle you must enable internet in the settings panel on the right."
+        )
+        return False

     # perform a login
     logged_in = wlogin.login()
diff --git a/wandb/sdk_py27/wandb_run.py b/wandb/sdk_py27/wandb_run.py
index e37643be..e1cb8639 100644
--- a/wandb/sdk_py27/wandb_run.py
+++ b/wandb/sdk_py27/wandb_run.py
@@ -76,6 +76,7 @@ if wandb.TYPE_CHECKING:  # type: ignore
         PollExitResponse,
     )
     from .wandb_setup import _WandbSetup
+    from .wandb_artifacts import Artifact
     from wandb.apis.public import Api as PublicApi

     from typing import TYPE_CHECKING
@@ -601,6 +602,66 @@ class Run(object):
         """
         return self.project_name()

+    def log_code(
+        self,
+        root = ".",
+        name = None,
+        include_fn = lambda path: path.endswith(".py"),
+        exclude_fn = lambda path: os.sep + "wandb" + os.sep
+        in path,
+    ):
+        """
+        log_code() saves the current state of your code to a W&B artifact.  By
+        default it walks the current directory and logs all files that end with ".py".
+
+        Arguments:
+            root (str, optional): The relative (to os.getcwd()) or absolute path to
+                recursively find code from.
+            name (str, optional): The name of our code artifact.  By default we'll name
+                the artifact "source-$RUN_ID".  There may be scenarios where you want
+                many runs to share the same artifact.  Specifying name allows you to achieve that.
+            include_fn (callable, optional): A callable that accepts a file path and
+                returns True when it should be included and False otherwise.  This
+                defaults to: `lambda path: path.endswith(".py")`
+            exclude_fn (callable, optional): A callable that accepts a file path and
+                returns True when it should be excluded and False otherwise.  This
+                defaults to: `lambda path: False`
+
+        Examples:
+            Basic usage
+            ```
+            run.log_code()
+            ```
+
+            Advanced usage
+            ```
+            run.log_code("../", include_fn=lambda path: path.endswith(".py") or path.endswith(".ipynb"))
+            ```
+
+        Returns:
+            An `Artifact` object if code was logged
+        """
+        # TODO: should this type be a special wandb type?
+        name = name or "{}-{}".format("source", self.id)
+        art = wandb.Artifact(name, "code")
+        files_added = False
+        if root is not None:
+            root = os.path.abspath(root)
+            for file_path in filenames.filtered_dir(root, include_fn, exclude_fn):
+                files_added = True
+                save_name = os.path.relpath(file_path, root)
+                art.add_file(file_path, name=save_name)
+        # Add any manually staged files such is ipynb notebooks
+        for dirpath, _, files in os.walk(self._settings.code_dir):
+            for fname in files:
+                file_path = os.path.join(dirpath, fname)
+                save_name = os.path.relpath(file_path, self._settings.code_dir)
+                files_added = True
+                art.add_file(file_path, name=save_name)
+        if not files_added:
+            return None
+        return self.log_artifact(art)
+
     def get_url(self):
         """
         Returns:
diff --git a/wandb/sdk_py27/wandb_settings.py b/wandb/sdk_py27/wandb_settings.py
index a9143f95..408c779f 100644
--- a/wandb/sdk_py27/wandb_settings.py
+++ b/wandb/sdk_py27/wandb_settings.py
@@ -215,6 +215,8 @@ class Settings(object):
     sync_file_spec = None
     sync_dir_spec = None
     files_dir_spec = None
+    tmp_dir_spec = None
+    code_dir_spec = None
     log_symlink_user_spec = None
     log_symlink_internal_spec = None
     sync_symlink_latest_spec = None
@@ -318,9 +320,12 @@ class Settings(object):
         log_symlink_internal_spec="{wandb_dir}/debug-internal.log",
         resume_fname_spec="{wandb_dir}/wandb-resume.json",
         files_dir_spec="{wandb_dir}/{run_mode}-{timespec}-{run_id}/files",
+        tmp_dir_spec="{wandb_dir}/{run_mode}-{timespec}-{run_id}/tmp",
+        code_dir_spec="{wandb_dir}/{run_mode}-{timespec}-{run_id}/tmp/code",
         symlink=None,  # probed
         # where files are temporary stored when saving
         # files_dir=None,
+        # code_dir=None,
         # data_base_dir="wandb",
         # data_dir="",
         # data_spec="wandb-{timespec}-{pid}-data.bin",
@@ -506,6 +511,14 @@ class Settings(object):
     def files_dir(self):
         return self._path_convert(self.files_dir_spec)

+    @property
+    def tmp_dir(self):
+        return self._path_convert(self.tmp_dir_spec)
+
+    @property
+    def code_dir(self):
+        return self._path_convert(self.code_dir_spec)
+
     @property
     def log_symlink_user(self):
         return self._path_convert(self.log_symlink_user_spec)
@@ -786,6 +799,16 @@ class Settings(object):
             u["_jupyter_path"] = meta.get("path")
             u["_jupyter_name"] = meta.get("name")
             u["_jupyter_root"] = meta.get("root")
+        elif self._jupyter and os.path.exists(self.notebook_name):
+            u["_jupyter_path"] = self.notebook_name
+            u["_jupyter_name"] = self.notebook_name
+            u["_jupyter_root"] = os.getcwd()
+        elif self._jupyter:
+            wandb.termwarn(
+                "WANDB_NOTEBOOK_NAME should be a path to a notebook file, couldn't find {}".format(
+                    self.notebook_name
+                )
+            )

         # host and username are populated by env_settings above if their env
         # vars exist -- but if they don't, we'll fill them in here
diff --git a/wandb/util.py b/wandb/util.py
index 8fcbc624..6bf23e39 100644
--- a/wandb/util.py
+++ b/wandb/util.py
@@ -16,6 +16,7 @@ import math
 import os
 import re
 import shlex
+import socket
 import subprocess
 import sys
 import threading
@@ -1165,6 +1166,16 @@ def uri_from_path(path):
     return url.path if url.path[0] != "/" else url.path[1:]


+def _has_internet():
+    """Attempts to open a DNS connection to Googles root servers"""
+    try:
+        s = socket.create_connection(("8.8.8.8", 53), 0.5)
+        s.close()
+        return True
+    except OSError:
+        return False
+
+
 @contextlib.contextmanager
 def fsync_open(path, mode="w"):
     """
